feat(be,fe): SSO sign-in via two-hop OIDC discovery#3785
feat(be,fe): SSO sign-in via two-hop OIDC discovery#3785aterga merged 15 commits intodfinity:mainfrom
Conversation
19f0eac to
a9731ba
Compare
…ecurity (dfinity#3784) ## Summary Add the `aud` (audience / client_id) field to `OpenIdCredentialKey`, changing it from `(iss, sub)` to `(iss, sub, aud)`. This is a security prerequisite for SSO: since SSO allows anyone to provide a `client_id` via their `ii-openid-configuration` endpoint, without `aud` in the key two different OIDC clients at the same provider with the same user `sub` would collide, enabling impersonation. ## Changes - **Type update**: `OpenIdCredentialKey` type alias changed from `(Iss, Sub)` to `(Iss, Sub, Aud)` in both `internet_identity_interface` and the `openid` module - **CBOR encoding**: `StorableOpenIdCredentialKey` rewritten with manual `Encode`/`Decode` impls — new entries use CBOR map format `{0:iss, 1:sub, 2:aud}`; the decoder also handles legacy CBOR array format `[iss, sub]` for backward compatibility - **Migration**: `post_upgrade` drains the credential key index via `pop_first`, resolves `aud` from each anchor's `StorableOpenIdCredential` (which already stores `aud` at CBOR index `#[n(2)]`), and re-inserts with the complete `(iss, sub, aud)` key. Unresolvable entries are preserved with empty `aud` for retry on next upgrade. - **Key construction**: Updated `OpenIdCredential::key()`, `StorableOpenIdCredential::key()`, `calculate_delegation_seed()`, and all call sites - **Candid interface**: Updated `.did` file and generated JS/TS declarations - **Frontend**: Updated credential removal call to pass `aud` - **Tests**: Added unit tests for new CBOR map encoding, legacy array decoding, and round-trip serialization. Updated existing test assertions to use 3-tuple keys. ## Delegation seed backward compatibility The `calculate_delegation_seed` function already receives `client_id` (which equals `aud`) as a separate parameter. The seed calculation is unchanged — `aud` from the key tuple is ignored (`_aud`) in the destructuring, preserving identical `Principal` derivation for existing credentials. ## Migration safety - Uses `pop_first()` to drain the BTreeMap, avoiding byte-level encoding mismatches between legacy array-encoded keys and new map-encoded keys - Resolves `aud` from the anchor's stored `StorableOpenIdCredential` which already has `aud` at CBOR index 2 - Falls back to re-inserting with empty `aud` if resolution fails, with a logged warning — the entry is preserved for retry on next upgrade - Idempotent: safe to run on every upgrade; entries already in the new format are preserved unchanged ## Test plan - [x] All 209 unit tests pass (including Candid interface compatibility) - [ ] Integration tests (require canister WASM build — pass in CI) - [ ] Deploy to testnet and verify migration of existing credentials - [ ] Verify credential lookup works after migration - [ ] Verify new credential registration includes `aud` in key --- [< Previous PR](dfinity#3778) | [Next PR >](dfinity#3785) --------- Co-authored-by: Claude Agent <noreply@anthropic.com> Co-authored-by: Arshavir Ter-Gabrielyan <arshavir.ter.gabrielyan@dfinity.org>
There was a problem hiding this comment.
Pull request overview
Adds frontend-side OIDC discovery for a new “discoverable” provider configuration list (oidc_configs), enabling the UI/auth flow to fetch provider discovery documents on demand (instead of relying solely on backend-provided openid_configs).
Changes:
- Extend backend config decoding/types to include
oidc_configs(discoverable providers withdiscovery_url+ optionalclient_id). - Add
oidcDiscovery.tswith caching/rate limiting/validation and integrate it into auth + OpenID config lookup. - Render additional provider buttons for
oidc_configsand add unit tests for discovery + config lookup.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/frontend/src/lib/utils/openID.ts | Extend findConfig() to synthesize an OpenIdConfig from cached OIDC discovery + oidc_configs. |
| src/frontend/src/lib/utils/openID.test.ts | Add tests covering findConfig() behavior with oidc_configs and discovery cache mocking. |
| src/frontend/src/lib/utils/oidcDiscovery.ts | New module implementing OIDC discovery fetch, validation, caching, concurrency + rate limiting. |
| src/frontend/src/lib/utils/oidcDiscovery.test.ts | New unit tests for discovery fetch, caching, and validation behavior. |
| src/frontend/src/lib/globals.ts | Add candid IDL + TS types to decode oidc_configs from .config.did.bin. |
| src/frontend/src/lib/flows/authFlow.svelte.ts | Add continueWithOidc() that fetches discovery and reuses the existing OpenID flow. |
| src/frontend/src/lib/components/wizards/auth/views/PickAuthenticationMethod.svelte | Render provider buttons for oidc_configs and wire them to a new handler. |
| src/frontend/src/lib/components/wizards/auth/AuthWizard.svelte | Wire continueWithOidc handler into the auth wizard flow. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
0ebd6e2 to
3d587a1
Compare
41c03a6 to
815d71e
Compare
7a63b3d to
8774fda
Compare
Address review feedback on dfinity#3785: - **`selectAuthScopes` always includes `openid`** (required by OIDC spec), regardless of what the provider advertises. Non-spec compliant providers that omit `openid` from `scopes_supported` still work. - **Trim `TRUSTED_PROVIDER_DOMAINS` to only `dfinity.okta.com`.** Google / Apple / Microsoft are served via `openid_configs` (not SSO); `login.dfinity.org` was a hallucination. - **Zod schemas for wire data** (`ii-openid-configuration`, OIDC discovery docs) replace hand-rolled structural checks. - **Drop `oidc_configs` from `InternetIdentitySynchronizedConfig`** (Rust struct + Candid + FE decode). The frontend doesn't need a pre-baked SSO allowlist — the SSO screen calls `add_discoverable_oidc_config` on submit, so wire-shipping registered domains served no consumer. - **Drop stale `oidc_configs: []` from the `backendCanisterConfig` test mock** in `openID.test.ts`. - **Revert out-of-scope subtitle wording changes** ("Choose an authentication method to continue" → "Choose method to continue") on all three sign-in entry points; re-extract locales accordingly. - Test assertions updated to match zod's error-message format. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`TRUSTED_PROVIDER_DOMAINS` on the frontend was redundant with the backend's canary allowlist (`ALLOWED_DISCOVERY_DOMAINS`): - A domain can only reach `discoverSsoConfig` after `add_discoverable_oidc_config` succeeded on the backend, which traps for domains not on the admin-vetted canary list. - Once we've agreed that `dfinity.org` is trustworthy, whatever they publish at `/.well-known/ii-openid-configuration` represents their IdP choice — the org knows their own provider better than II does. - An attacker who can tamper with a trusted domain's `.well-known` has already broken something more fundamental than the second-hop allowlist would protect against. Removed `TRUSTED_PROVIDER_DOMAINS` and the matching check in `validateProviderUrl`. Retained checks that still carry their weight: - HTTPS on every URL in the chain. - Issuer hostname / authorization_endpoint hostname must match the `openid_configuration` hostname (prevents a tampered provider- discovery doc from bouncing auth off-host after we've committed to a provider). - Domain format validation on user input. Updated the module docstring to spell out where the first-hop trust actually comes from, and dropped the now-obsolete "rejects untrusted provider domains" test. Also means per-org onboarding no longer needs an II frontend deploy — a new SSO org just needs the admin call to `add_discoverable_oidc_config`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frontend error mapping in SignInWithSso now distinguishes each distinct failure mode of the two-hop chain and includes the underlying detail where it is actionable: - HTTP-error from hop 1 → "X didn't serve /.well-known/...-configuration (HTTP 404). The domain owner needs to publish it for II to sign you in." - Network failure from hop 1 → "Couldn't reach X. Check the spelling and your network, then try again." - Malformed hop-1 response → includes the zod error detail (e.g. "expected string, received undefined at client_id") or the HTTPS check message. - Hop-2 hostname mismatches (issuer / auth endpoint) → short human summary + the raw message for context. - Canary-allowlist trap → "Ask an II admin to register this domain." - Rate-limit / concurrency errors → friendly paraphrase. `DomainNotConfiguredError.detail` is now a public field so the UI can read the inner error text instead of collapsing to "invalid response". --- Also temporarily adds `s4i6f-riaaa-aaaad-agnna-cai.icp0.io` to `ALLOWED_DISCOVERY_DOMAINS` alongside `dfinity.org`, so the two-hop flow can be exercised end-to-end from a test canister on staging-C. DO NOT MERGE: the test entry must be removed before this PR lands on main. The production canary allowlist stays at just `dfinity.org`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
An SSO-linked access method (e.g. via `dfinity.org`'s Okta) was being rendered as "Google account" with the Google logo because `findConfig(iss, metadata)` matched on issuer alone. When the underlying IdP happens to be Google, the credential's `iss` is the Google issuer and collides with the direct "Sign in with Google" entry in `openid_configs` — even though the `aud` (client_id) is completely different. Changes: - `findConfig(iss, aud, metadata)` now matches on both `iss` and `aud` (the OAuth client_id). `aud` is accepted as `string | undefined`; callers that haven't been extended to track `aud` yet (`LastUsedIdentity`-based paths — see dfinity#3795) keep their issuer-only behavior for now, which is correct for direct providers and mis-attributes SSO credentials at those legacy sites only. - `openIdName(iss, sub, aud, metadata)` now also consults a per-device localStorage map (`ssoDomainStorage.ts`) populated at SSO link time. If the credential was linked via SSO on this device, the label becomes the `discovery_domain` the user typed (e.g. "dfinity.org account"). - `openIdLogo(iss, aud, metadata)` returns `undefined` for credentials that don't match any direct provider — the access-methods UI uses the generic SSO key icon as a fallback in that case. - `SsoDiscoveryResult` grows a `domain` field; both sign-in and add-access-method flows persist the `(iss, sub, aud) → domain` mapping after a successful SSO link, so the next render shows the correct label. - `OpenIdItem` renders the `<SsoIcon>` when `openIdLogo` returns undefined, and falls back to the literal "SSO" word when no domain is stored (cross-device case). Cross-device labelling (credential linked on device A, viewed on device B) remains wrong until the `discovery_domain` is persisted on the backend credential — tracked in dfinity#3795. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9a2518f to
6269805
Compare
My earlier `findConfig` change required a strict (iss, aud) match and
returned undefined otherwise, falling through to a generic SSO render.
That broke the inverse of the original bug: a direct-Google credential
whose stored `aud` doesn't line up with the current `openid_configs`
entry (e.g. after a client_id rotation) gets rendered as "SSO account"
with the key icon, erasing its real provenance.
Restructure the resolution so both kinds of credential land correctly:
- `findConfig(iss, aud, metadata)` now does strict-then-fallback:
1. Exact `(iss, aud)` match — authoritative.
2. Issuer-only match — covers legacy / rotated / migration-artifact
credentials so they still get the right provider label.
- `openIdName` / `openIdLogo` consult the per-device SSO map
(`ssoDomainStorage`) BEFORE calling `findConfig`. SSO credentials
linked on this device short-circuit to the SSO branch regardless of
what `findConfig` would return; everything else gets
`findConfig`'s strict-then-fallback answer.
Net behaviour:
- Direct Google, normal: strict match → "Google" + Google logo. ✓
- Direct Google, aud mismatch (legacy / rotation): issuer-only
fallback → "Google" + Google logo. ✓ (was broken before)
- SSO-via-Google linked on this device: localStorage hit → domain
name + `SsoIcon`. ✓
- SSO-via-Google on a different device: falls through to issuer-only
→ "Google" (mis-attributed, same as pre-PR behaviour). Needs the
backend to persist `discovery_domain` on the credential, tracked in
dfinity#3795.
Also threads `sub` into `openIdLogo` so the SSO short-circuit can
check the full `(iss, sub, aud)` key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`openid_credential_add` rejects with `OpenIdCredentialAlreadyRegistered` for both the case where the `(iss, sub, aud)` is attached to THIS identity and the case where it's on a DIFFERENT one. The UI used the generic "already linked to another identity" toast for both, which is misleading when the credential is in fact already on the identity the user is managing. Distinguish the two on the client: - `addAccessMethodFlow.linkOpenIdAccount` now catches the `OpenIdCredentialAlreadyRegistered` error, queries `get_anchor_info` for the current identity, and checks whether `(iss, sub, aud)` is already in `openid_credentials`. - If yes, rethrow as the new `OpenIdCredentialAlreadyLinkedHereError` (plain JS Error, not a canister error). - If no, re-throw the original canister error so it hits the existing "another identity" handler unchanged. `error.ts` gains a matching branch that shows "This account is already linked to this identity" for the new class; the original message is preserved for the truly-different-identity case. Covers both `linkOpenIdAccount` and `linkSsoAccount` since the latter delegates to the former. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 40 changed files in this pull request and generated 16 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The SSO screen was awaiting `add_discoverable_oidc_config` and `discoverSsoConfig` inside the Continue click handler, then handing off to `continueWithSso` which opens the OAuth popup. Safari blocks `window.open` when it follows any `await` after the user event — so the first click reliably failed on Safari, the pop-up never opened, and the user saw the Signing-in spinner go nowhere. Move the network-heavy lookup into a debounced input handler: - 200 ms after the user stops typing, the input handler runs `add_discoverable_oidc_config` + `discoverSsoConfig` and stashes the result in `preparedResult`. The button reports "Checking…" while this is in flight. - `preparedResult` drives the Continue button's enabled state: it lights up only once discovery is complete for the currently-typed domain. - The click handler does no awaiting before `continueWithSso`, so the chain `click → handleSubmit → continueWithSso → requestJWT → requestWithPopup → redirectInPopup → window.open` is fully synchronous. Safari allows the popup. Races: the typed input may change while a lookup is in flight. We compare the lookup's `trimmed` against the current `domain.trim() .toLowerCase()` before applying the result or the error so a stale response can't clobber a fresher one. `invalidatePrepared()` on every input cancels the debounce and the button goes back to disabled. Format validation still runs synchronously on input (no round-trip), and the error is only surfaced once the input looks like a complete domain (contains a dot) — so the user doesn't see "Invalid domain format" while still mid-typing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The direct-OpenID resume path in `authorize/+page.svelte` still called `findConfig(iss, metadata)` — the legacy 2-arg signature. After `findConfig` was updated to `(iss, aud, metadata)` for the SSO credential-disambiguation change, this call stopped compiling (svelte- check flagged "Expected 3 arguments, but got 2"). Destructure `aud` out of `decodeJWT(jwt)` and pass it through so the strict-then-fallback resolution runs the same way as everywhere else. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier `chore(fe): sync auto-extracted locale catalogs` commit picked up the new "Checking..." / "Sign In With SSO" / "Company domain" msgids, but also cleared the existing translations of "Choose method to continue" in 10 non-English catalogs. That msgid is still used by `/+page.svelte`, so the regression would have fallen back to English at runtime. Restore the prior translations from `origin/main` for de, es, fr, id, it, nl, pl, ru, uk, ur. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ssoDiscovery.ts header: drop the stale claim that callers must
independently check the domain against a backend list. The actual
gating is `add_discoverable_oidc_config` trapping on the canary
allowlist before `discoverSsoConfig` runs — state that directly.
- PickAuthenticationMethod.svelte: the comment next to the SSO entry
still claimed the SignInWithSso screen would reject every input with
"This domain is not registered…", but rejection lives on the canister
now. Describe the real behavior.
- SignInWithSso.svelte: the submit `<Button>` had both `type="submit"`
and `onclick={handleSubmit}` while the surrounding `<form>` also
calls `handleSubmit` in `onsubmit`. Clicking the button fired the
handler twice. Drop the onclick — the form's onsubmit covers both
Enter-in-input and button-click, and it still runs synchronously
from the trusted user gesture (critical for the Safari popup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SSO canary allowlist used to be a single const with both the real prod domain (`dfinity.org`) and a staging test canister host wearing a `DO NOT MERGE` sticker. Replace the const with `allowed_discovery_domains()`, which reads `is_production` from the persistent state and branches: - `is_production == Some(true)` (id.ai) → `dfinity.org` - otherwise (beta.id.ai, staging, local, CI) → `beta.dfinity.org` Keeping the two disjoint means a DNS takeover of the beta test domain can't backdoor the production canister, and it lets us stage new IdP registrations on beta without touching the production issuer. Also removes the `DO NOT MERGE` staging-C entry the earlier commit carried. Tests: - `should_add_oidc_config_via_update_call`, `should_coexist_with_ openid_configs`, `should_deduplicate_oidc_configs` switched to the default (non-prod) install and the beta domain. - `should_reject_disallowed_discovery_domain` extended to assert that the production domain is rejected on a non-prod canister. - New `should_allow_only_production_domain_on_production_canister` installs with `is_production: Some(true)` and asserts that only `dfinity.org` is accepted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…received"
When a user tries to sign in via SSO and the provider's OAuth app is
misconfigured — most often the Okta/Auth0/etc. app is set to
`response_types=[code]` only, and refuses our `response_type=id_token
code` hybrid request — the provider redirects back to II's callback
with something like:
#state=…&error=unsupported_response_type
&error_description=The+response+type+is+not+supported…
`requestWithPopup` used to just check for `id_token`, find it missing,
and throw `Error("No token received")`. That looks exactly like a bug
in II, sending admins down the wrong rabbit hole — the real fix is a
knob in the SSO app.
Changes:
- New `OAuthProviderError` in `openID.ts` carrying `error` +
`errorDescription` separately (RFC 6749 §4.1.2.1 / 4.2.2.1 fields).
- New `extractIdTokenFromCallback(callback, expectedState)` helper:
single source of truth for how we interpret a callback URL fragment,
and the unit-testable seam. It throws `OAuthProviderError` BEFORE
the `id_token` null-check, so a provider-side failure wins over the
generic fallback. `requestWithPopup` now delegates to it.
- `SignInWithSso.svelte#mapSubmitError` specializes
`unsupported_response_type` and `access_denied`, and otherwise falls
back to `{error}: {error_description}` — all interpolated with the
typed domain so the user knows which SSO they're debugging.
- `handleError` in `error.ts` handles `OAuthProviderError` for callers
that route through the generic error sink (direct-OpenID entry
points, not just SSO).
Tests:
- 6 new unit tests for `extractIdTokenFromCallback`: success, provider
error with and without description, state mismatch (including a
"forged-error" CSRF-shaped case where state is checked before error
so an attacker can't drive the UI via a crafted fragment), missing
state, and the "neither id_token nor error" fallback.
- 3 new unit tests for `OAuthProviderError`: message with/without
description, and `instanceof Error` (so existing catch blocks still
see it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sea-snake
left a comment
There was a problem hiding this comment.
Mostly nit comments, one non-nit comment that can be handled in a separate PR.
The SSO entry in PickAuthenticationMethod and AddAccessMethod leads into a flow that can end in either a sign-in or a sign-up, so "Sign in with SSO" is misleading. Rename the `aria-label` to "Continue with SSO" — matches the copy pattern used by the direct-provider buttons and the Continue button inside the SSO screen itself. The SignInWithSso screen's h1 is updated separately in the following commit. Addresses sea-snake's review comment on dfinity#3785. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four related fixes from sea-snake's review, best reviewed together
since they overlap on the same files:
1. **Slim SSO error mapping to user-actionable copy.** `mapSubmitError`
in `SignInWithSso.svelte` had a ladder of branches for every
provider-misconfiguration shape (hostname mismatch, non-HTTPS
issuer / auth endpoint, malformed discovery doc, ...). These
read as implementation-detail leakage to end users. Keep only the
cases the user (or their SSO admin) can act on — domain-not-
configured, canary allowlist, OAuth provider errors from the
callback fragment, rate limits — and fall back to a generic
"SSO sign-in for <domain> failed" for everything else. Always
`console.error` the raw error so engineers still have the stack.
2. **Disable Continue when the SSO is already linked to this
identity.** `SignInWithSso.svelte` takes an optional
`openIdCredentials` prop (passed in the add-access-method context,
left `undefined` in the sign-in context). After two-hop discovery
reveals the SSO's `(iss, aud)`, a `$derived` `isAlreadyLinked`
checks against the prop and disables the Continue button with
an inline hint. Mirrors the direct-provider (Google/Apple/...)
button-disable in `AddAccessMethod.svelte` — reaching the
canister's `OpenIdCredentialAlreadyRegistered` for this identity
is no longer possible, so the `OpenIdCredentialAlreadyLinkedHereError`
specialization in `linkOpenIdAccount` / `handleError` is removed
as unreachable.
3. **Move SSO domain + name from FE localStorage to canister-stamped
metadata.** The previous iteration used `ssoDomainStorage.ts` (a
per-device localStorage map of `(iss, sub, aud) → domain`) to
label SSO credentials by the domain the user typed. That's
unreliable across devices and a lot of FE bookkeeping for something
the canister already has. The canister now stamps two new metadata
keys on any credential verified by a `DiscoverableProvider`:
- `sso_domain` — the `discovery_domain` the user entered
(canonical SSO label; always present for SSO credentials).
- `sso_name` — the `name` field from
`{domain}/.well-known/ii-openid-configuration` if the domain
publishes one (e.g. `"DFINITY"`). Optional.
The `IIOpenIdConfiguration` hop-1 schema gets a new optional
`name: Option<String>` (serde `#[serde(default)]` so older
deployments that don't publish the field still parse).
`DiscoverableProvider` / `DiscoveryState` carry `discovery_domain`
and a `discovered_name` ref that hop-1 populates each refresh.
4. **Render the SSO `name` (with domain fallback).** `openIdName`
now reads `sso_name` → `sso_domain` → `findConfig(iss, aud).name`,
so an SSO credential linked via a domain that publishes
`name: "DFINITY"` renders as "DFINITY", and one that doesn't
renders as "dfinity.org". `openIdLogo` returns `undefined` when
`sso_domain` is present (generic SSO icon). `OpenIdItem.svelte`
detects SSO via `sso_domain` directly instead of the "no logo
found" heuristic — which was brittle for direct-provider credentials
whose issuer didn't match any `openid_configs` entry.
Removes `ssoDomainStorage.ts` and its callers in
`authFlow.svelte.ts` and `addAccessMethodFlow.svelte.ts`. The SSO
sign-in flow no longer needs to pre-decode the JWT either — the
canister handles labeling — so `continueWithSso` collapses to
`return this.continueWithOpenId(syntheticConfig)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…finity#3803) Follow-up to dfinity#3785 addressing [@sea-snake's review](dfinity#3785 (review)). All five unresolved threads there are addressed here, and will be marked resolved on dfinity#3785 with pointers back to this PR. Stacked on top of dfinity#3785: this branch includes all commits from dfinity#3785 plus three new commits that cover the review fixes. # Changes ## 1. `Sign in with SSO` → `Continue with SSO` SSO button `aria-label` in `PickAuthenticationMethod` and `AddAccessMethod`, and the h1 inside the SSO screen, now read "Continue with SSO" — the downstream flow can end in either a sign-in or a sign-up, so "Sign in" was misleading. ## 2. Slim `mapSubmitError` to user-actionable copy `SignInWithSso.svelte#mapSubmitError` used to enumerate every shape of provider misconfiguration (hostname mismatch, non-HTTPS endpoints, malformed discovery document, ...). Those read as implementation-detail leakage. Keep only the branches the user or their SSO admin can act on — domain not configured, canary allowlist, OAuth provider errors from the callback fragment, rate limits — and fall back to a generic `"SSO sign-in for <domain> failed"` for everything else. Every thrown error is `console.error`'d unconditionally so engineers still have the stack. ## 3. Disable Continue when the SSO is already linked to this identity `SignInWithSso` takes a new optional `openIdCredentials` prop. In the add-access-method flow, `AddAccessMethodWizard` passes the identity's current credentials through; once two-hop discovery reveals the SSO's `(iss, aud)`, a `$derived` check disables the Continue button with an inline hint if that SSO is already linked. Mirrors how `AddAccessMethod.svelte` already disables direct-provider buttons for already-linked providers. Left `undefined` in the sign-in flow, where reusing an existing credential is the point. This makes reaching `OpenIdCredentialAlreadyRegistered` for *this* identity impossible, so the `OpenIdCredentialAlreadyLinkedHereError` specialization in `linkOpenIdAccount` and the corresponding toaster branch in `handleError` are dropped as unreachable. ## 4 + 5. Move SSO domain + name from FE localStorage to canister-stamped credential metadata `ssoDomainStorage.ts` (a per-device localStorage map of `(iss, sub, aud) → domain`) is removed. The canister now stamps two new metadata keys on any credential verified by a `DiscoverableProvider`: - `sso_domain` — the `discovery_domain` the user entered at sign-up. The canonical SSO label; always present for SSO credentials. - `sso_name` — optional human-readable name from `{domain}/.well-known/ii-openid-configuration`. When the domain publishes `name: "DFINITY"`, the access-methods list reads "DFINITY account" instead of "dfinity.org account". BE: - `IIOpenIdConfiguration` hop-1 schema gets `name: Option<String>` (with `#[serde(default)]` so older deployments still parse). - `DiscoverableProvider` and `DiscoveryState` carry `discovery_domain` and a `discovered_name` ref that hop-1 populates each refresh. - `DiscoverableProvider::verify()` inserts the two keys before returning the credential. FE: - `openIdName` now resolves `sso_name` → `sso_domain` → `findConfig(iss, aud, metadata).name`, so an SSO-via-Google credential reads as "DFINITY" (or "dfinity.org" if the domain doesn't publish a name), not "Google". - `openIdLogo` returns `undefined` when `sso_domain` is present (generic SSO icon). - `OpenIdItem.svelte` detects SSO via `sso_domain` directly, instead of the previous "no logo found" heuristic that was brittle for direct-provider credentials whose issuer didn't match any `openid_configs` entry. - `authFlow#continueWithSso` and `addAccessMethodFlow#linkSsoAccount` both simplify — no pre-decoding the JWT or remembering the domain locally — because the canister handles the labeling end-to-end, so the mapping survives reloads and crosses devices. # Tests - `cargo test -p internet_identity --bin internet_identity`: **219/219 pass**. - `cargo clippy --all-targets -D warnings`: clean. - `cargo fmt --check`: clean. - `npm run check` (tsc + svelte-check): 0 errors (19 pre-existing warnings unchanged). - `npm run lint` + `prettier --check`: clean. --- [< Previous PR](dfinity#3785) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Problem
Internet Identity users can sign in with personal Google/Apple/Microsoft accounts, but there's no way for an organization to let their employees sign in with their own SSO domain (e.g.
alice@dfinity.org) while still having II act as the identity provider. Each new organization would otherwise require a code change on II to add their issuer, client id, and OAuth endpoints.Solution
A user types their organization's domain on the SSO screen; the frontend calls
add_discoverable_oidc_configto register the domain (gated by the backend's canary allowlist), then runs a two-hop discovery chain to resolve the provider's OAuth endpoint, then redirects them to sign in.Registration is user-initiated: the SSO screen itself drives the canister update call. An II admin still has to land a new domain in the backend's canary allowlist (
openid::generic::allowed_discovery_domains()) via canister upgrade — what's new is that, once that's done, individual users can register their own org's domain via the SSO screen instead of the config living inline in II's init args.The allowlist is gated on the deployment's
is_productioninit flag so the two mainnet canisters don't share a domain: onid.aionlydfinity.orgis accepted, onbeta.id.ai(and everywhere else — staging, local, CI) onlybeta.dfinity.org. Keeping them disjoint means a DNS takeover of the beta test domain can't backdoor production, and we can stage new IdP wiring onbeta.dfinity.orgwithout risking the prod issuer.Replaces #3786, which is closed.
Changes
Backend
Removed:
oidc_configsfrom init args and synchronized configInternetIdentityInitpreviously carriedoidc_configs, but applying it had never been wired throughapply_install_arg— it was silently dropped on install/upgrade. Removed the field entirely fromInternetIdentityInit,InternetIdentitySynchronizedConfig, the Candid interface,config(), and theFrom<&InternetIdentityInit> for InternetIdentitySynchronizedConfigimpl. Registration goes exclusively through theadd_discoverable_oidc_configupdate call from here on, and/.config.did.binonly carriesopenid_configs(the direct Google/Apple/Microsoft configs).add_discoverable_oidc_configTraps for any domain not in
ALLOWED_DISCOVERY_DOMAINS, otherwise inserts the domain into persistent state and kicks off a backend-side two-hop discovery (to populate JWKS for signature verification on subsequent sign-ins). The canister also exposesdiscovered_oidc_configsfor querying resolved SSO provider state.Frontend
Type alignment with backend.
DiscoverableOidcConfigis{ discovery_domain: string }.Two-hop discovery (
ssoDiscovery.ts, new).GET https://{domain}/.well-known/ii-openid-configurationreturns{ client_id, openid_configuration }. The domain owner publishes this at their DNS-backed origin.GET {openid_configuration}is the provider's standard OIDC discovery, yieldingauthorization_endpointandscopes_supported.Both hops run from the browser. (The backend has its own copy in
openid/generic.rs; keeping the implementations separate for now minimizes BE↔FE synchronization.)SSO flow UI.
SignInWithSso.svelte(new): domain input screen. Framed icon chip, title"Sign In With SSO", subtitle"Enter your company domain", placeholder"company.domain.com". Input triggers a debounced (200ms) lookup:anonymousActor.add_discoverable_oidc_config(idempotent; traps if the domain isn't on the backend allowlist) →discoverSsoConfig. The Continue button enables only once the lookup succeeds; clicking it opens the OAuth popup synchronously from the user gesture (critical for Safari). Canary-allowlist traps are mapped to"SSO is not available for \"<domain>\" yet."inline.SsoIcon.svelte(new): key icon.PickAuthenticationMethod.svelte: "Continue with passkey" is the top button; OIDC provider icons + SSO key icon render in the row below at equal width. The SSO icon is always rendered — users always see SSO as an option, regardless of whether any domain is registered.authFlow.svelte.ts: newsignInWithSsoview state andcontinueWithSso()method."Choose method to continue"→"Choose an authentication method to continue".SSO vs direct-provider credential disambiguation.
An SSO credential can arrive via the same underlying IdP (e.g. Google) as a direct-Google credential. They differ in
aud(theclient_id), but two things broke simple issuer-only matching:client_idrotation.Resolved via three-tier lookup: strict
(iss, aud)match → localStorage SSO-domain map ({iss, sub, aud} → domain) → issuer-only fallback. Credentials linked via SSO remember the typed domain in localStorage so they can be surfaced by domain in the access-methods UI on the same device. Cross-device labeling needs backend support and is tracked in #3795.Safari popup. The OAuth popup must open synchronously from the click event or Safari blocks it. The SSO lookup (
add_discoverable_oidc_config+ two-hop) is done in a debounced input handler, stashed in local state, and consumed by the click handler with no intervening awaits beforewindow.open.Already-linked disambiguation. If the backend returns
OpenIdCredentialAlreadyRegistered, the FE queriesget_anchor_infoand distinguishes "already linked to this identity" (specializedOpenIdCredentialAlreadyLinkedHereError) from "already linked to another identity" (the generic error).Cleanup.
oidc_configsremoved from the frontend'sBackendCanisterConfigdecode schema (no remaining FE consumer after the refactor). Candid is forward-compatible, so the backend continues to accept old init-arg shapes that may have includedoidc_configs.Security.
allowed_discovery_domains(), gated onis_production) is the sole source of truth for which domains can register. The frontend does not carry its own copy — the gate lives on the canister where a compromised device can't bypass it./.well-known/ii-openid-configuration, provider discovery, authorization endpoint) must be HTTPS.authorization_endpointhostnames must match theopenid_configurationhostname exactly or as a true subdomain (prevents a tampered provider-discovery doc from bouncing auth off-host after we've committed to a provider).endsWithalone would accept look-alikes likeevildfinity.okta.com..well-knownhas already breached something more fundamental than any FE-side allowlist would catch, and the org knows its own IdP better than II does.clearTimeoutinfinally.Tests
config/oidc_configs.rscovers both branches of theis_production-gated allowlist: default install rejectsdfinity.org(and acceptsbeta.dfinity.org),is_production: Some(true)install rejectsbeta.dfinity.org(and acceptsdfinity.org).should_coexist_with_openid_configsnow queries SSO state viadiscovered_oidc_configsinstead of the removedconfig().oidc_configs.ssoDiscovery.test.ts.openID.test.ts, including 4 forselectAuthScopesand the(iss, aud)strict-then-fallback resolution for direct-vs-SSO credentials.cargo test -p internet_identity --bin internet_identity: 219/219 pass.cargo clippy --all-targets -D warnings: clean.npm run lint+svelte-check: clean.< Previous PR